Skip to content

feat(proxy): coalesce the duplicate session-start sidecar send (opt-in) - #337

Open
Gunther-Schulz wants to merge 4 commits into
cnighswonger:mainfrom
Gunther-Schulz:pr/coalesce-sidecar
Open

feat(proxy): coalesce the duplicate session-start sidecar send (opt-in)#337
Gunther-Schulz wants to merge 4 commits into
cnighswonger:mainfrom
Gunther-Schulz:pr/coalesce-sidecar

Conversation

@Gunther-Schulz

Copy link
Copy Markdown
Contributor

Claude Code sends the same session-start sidecar request twice, ~6–25 ms apart, byte-identical. Both are answered, and both are charged. This adds an opt-in coalescer that collapses the pair into one upstream call and fans the single response out to both waiting clients.

Off by default. It does nothing unless CACHE_FIX_COALESCE_SIDECAR=1 is set.

The measurement

Not an inference from the code — this is what one day's traffic on one machine looks like, joined against the completion records by request id:

144 duplicate pairs / 114 streaks (longest streak: 11 sends)
258 requests involved
134 of those requests billed
 55 streaks billed more than once

The session-start class specifically: haiku, nMsg=1, max_tokens=32000, arriving at capture lines 3–5, 6–25 ms apart. 47 instances in one measurement window.

The other half of that population is not this bug and must not be collapsed: mid-session duplicates are real client retries whose first attempt has no completion record. Suppressing those would leave a genuine request unanswered. That distinction is the whole design constraint here.

What it does

Four conditions must all hold before anything is coalesced, and they exist to keep the retry class out:

  • the request matches the session-start sidecar shape;
  • a leader for the same coalesce key is in flight;
  • it arrived inside a 50 ms window (COALESCE_WINDOW_MS);
  • the bodies are byte-identical.

The follower then waits on the leader's response and receives the same bytes. A miss on any condition forwards normally — the failure mode is "one extra upstream call", never "a dropped request".

Tests

test/duplicate-coalesce.test.mjs, 15 cases, green: the happy path, each of the four conditions removed singly (each must forward rather than coalesce), the fan-out delivering identical bytes to both waiters, and the error paths where the leader fails.

npm test on this branch: 1796 tests, 1795 pass, 0 fail (one skip, pre-existing).

Scope, and what deliberately is not here

Two files plus one helper. The second commit carries tools/tmpdir.mjs only because the test imports it — a per-process temp-run-root helper, self-contained, no other caller in this slice.

Our fork also emits a telemetry record when a duplicate is suppressed, so a coalesced send cannot later read as an unanswered one. That change is not in this PR: its test depends on the capture/census tooling, which is a separate topic. Ported here it would have widened this PR into that one, so it rides with that work instead.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RMiYvNxKq6G9gfJMzArm4q

Gunther-Schulz added a commit to Gunther-Schulz/claude-code-cache-fix that referenced this pull request Aug 14, 2026
…on because a gate said so

PR cnighswonger#337 carries the coalescer itself, opt-in behind its env gate, with the
measured population in the body — 144 pairs, 114 streaks, 55 billed twice —
and the retry class stated as the design constraint rather than as a caveat,
since suppressing those would leave a real request unanswered.

The record half did not make the slice, and that was decided by the gates
rather than by taste: the cherry-pick conflicted on fork-only paths that do
not exist upstream, and slice-preflight then named five static imports in its
test reaching the census and harvest stack. Ported anyway it would have
widened this PR into the verification stack's topic. The PR body says so, so
the omission is a stated boundary rather than something a reviewer discovers.

Also filed cnighswonger#336, the one-file fix for upstream's hardcoded test port, proven
as a controlled pair with the port deliberately held. Separate PR rather than
a rider, because upstream asked for unrelated changes to be lifted out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMiYvNxKq6G9gfJMzArm4q
@vsits-proxy-builder

Copy link
Copy Markdown
Contributor

Reviewed at f4ca4d1. Load-bearing proxy request-path change; opt-in via CACHE_FIX_COALESCE_SIDECAR=1, off by default.

What's strong

  • Off-by-default gate posture is right for a mechanism whose failure mode changes billing attribution.
  • 15 wire-level tests against a real proxy + real local upstream, not mocks. The mid-session arm asserting TWO upstream calls (not one) is the discriminator — an assertion that only proved "one call for the coalescing case" would pass a build that coalesced everything.
  • Mutation-proof commentary on the byte-equal check (server.mjs:158-163) is exemplary — the author caught that Buffer.equals next to a full-length sha256 was an unfalsifiable predicate whose only red input is a hash collision, and removed it.
  • coalesceCandidate cleanly separates the per-request half of the predicate from the pair-scoped half, and exports it for direct test.
  • Fan-out replay-buffer design (mid-stream attach gets the whole response) is correct and covered.

Blocking

1. Shared-proxy billing/auth leak — not documented.

The coalesce key is sha256(forwardBody) only (server.mjs:151-153). On a shared / multi-tenant proxy, two different users' session-start sidecars are byte-identical (fixed sidecar shape — same model, same max_tokens: 32000, same nMsg=1 no-tools payload). Consequences when the gate is on:

  • Billing: leader's upstream call is charged to leader's account; follower's request is served from the leader's response and never bills follower's account.
  • Auth propagation: if the leader has bad auth (401 from upstream), followers with valid auth still get the leader's 401 (server.mjs:196-198 write to sink, which is the fan-out).
  • Response context: follower receives bytes derived from the leader's plan tier, org, etc.

This is the auth-isolation concern that shared-proxy operators need called out explicitly. The mechanism ships with the right gate posture, but the README has no section for CACHE_FIX_COALESCE_SIDECAR, and the existing shared-proxy warning (README:148, README:1443) talks about crash semantics only.

Two acceptable resolutions, either fine:

  • (a) Add a README section (matching the shape of image-retry / session-budget) documenting the env var, and next to it a warning: "single-tenant only; enabling on a multi-tenant proxy leaks billing attribution and auth-error propagation between users of identical sidecar payloads." Also add one line in the server.mjs comment near the gate check.
  • (b) Include an auth-distinguishing input in the coalesce key (e.g., first N bytes of a stable identity header). Adds one line; removes the operator-warning burden entirely.

I don't have a preference between the two.

Non-blocking suggestions

2. tools/tmpdir.mjs scope. The utility is 201 LOC including staleRunRoots scanning that has no caller in this PR — the author flags this as "self-contained, no other caller in this slice" but the module carries scaffolding (staleRunRoots, RUN_ROOT_PREFIX export, PID-scoped naming for the age-threshold discriminator) for a broader gate-live workflow that isn't in scope here. Trimming to tmpDir + tmpDirSync + their ensureRunRoot / removeRunRoot dependencies would land the same test capability at maybe 80 LOC. The rest can ride with the PR where it's actually called.

3. inFlightSidecars Map has no cap. Entries clean on clientRes.close, but a slow-death path (client hangs, close never fires) leaks entries. Under the gate-off default this is a non-issue; under the gate-on with pathological clients it grows unbounded. A small LRU (say 256) or a timer sweep for entries older than 2×COALESCE_WINDOW_MS would bound it. Not blocking because the gate is off by default and the practical exposure is small.

4. Off-by-default merits a README section anyway. Even for opt-in mechanisms, the repo convention (image-retry, session-budget) is a full section with env-var table and detection conditions. A reader browsing the README won't find this feature without one.

Verdict

Applying changes-requested. Not adding approved-by-code-agent.

Load-bearing per CLAUDE.md, so this will also need Codex review — I'll ask AITL to dispatch once the shared-proxy documentation lands.

— Proxy Builder

@vsits-proxy-builder vsits-proxy-builder Bot added the changes-requested Blocking review findings are outstanding label Aug 15, 2026
Gunther-Schulz and others added 3 commits August 20, 2026 20:51
…ated off

CC issues one sidecar request twice, 6-25 ms apart on 47/47 measured pairs,
with distinct upstream request-ids and two completed usage-log records --
both answered, both charged. Dropping the second is unavailable, since two
client requests are in flight and each is owed a response, so the only safe
shape is one upstream call serving both callers.

Four conditions, all required, and the mid-session duplicate class -- where
the second send is a legitimate retry -- fails on nMsg alone, which is the
discriminator the row asked for.

Fan-out sits at the RESPONSE WRITER, not at the upstream reader: the
extension pass and the telemetry record run once, so both callers receive
byte-identical post-pipeline output. Tee-ing the raw upstream would hand the
follower unmutated bytes while the leader got the pipeline's, and fidelity
outranks cache here.

TWO ARMS WERE NOT DISCRIMINATING AND THE MUTATION PROOF IS WHAT SHOWED IT.
Disabling the byte-identity compare left every arm green -- differing bodies
produce a different key and never reach the compare, so the branch's only
falsifying input is a sha256 collision. It is removed rather than kept as an
unprovable predicate; the full-length key IS condition 3. Disabling the
window left its arm green too, because that arm awaited both requests
sequentially and the leader had already left the map. Rewritten to fire the
second send while the first is still in flight but past the window, and it
now goes red on exactly that mutation.

Gated OFF (CACHE_FIX_COALESCE_SIDECAR). Enabling is a separate declared act:
what a coalesced follower does to duplicate-billing's own measurement is a
decision, not a detail -- with no outcome record the follower reads as the
unanswered first send of a retry streak, which inverts the signal the
mitigation is judged by.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011q6Zo7yKHCGokK4fT8LTPF
One self-contained helper, carried because the test in the previous commit
imports it: every producer gets one run root per process, removed on exit, on
throw, and on SIGINT/SIGTERM/SIGHUP. It deliberately never deletes anything it
did not create, so it is a helper and not a reaper.

Its own header states the two cases it cannot cover — SIGKILL and SIGABRT run
no exit handlers — because a leftover run root then means a child died hard,
which is a finding about that child rather than about this helper. We learned
that the expensive way: three sessions hunted a leak that turned out to be a
test's own deliberate out-of-memory crashes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMiYvNxKq6G9gfJMzArm4q
The rebase onto main landed upstream's fix for the abort guard — the
close handler moved from clientReq (which fires on every request the
moment its body is consumed) to clientRes paired with writableEnded.
That fix and this branch's fan-out meet in the same three lines, and
their combination was covered by nothing.

Under coalescing, "this response closed without being answered" stops
meaning "nobody needs the upstream call": the leader's fan-out may be
carrying followers. The guard therefore asks the fan-out's live-sink
count before aborting, and only when a fan-out is in play — the plain
path keeps upstream's guard unchanged.

The case: leader opens, follower attaches inside the window, leader's
client hangs up mid-stream, follower must still receive message_stop.
Both arms measured. Against the naive guard it goes red and the other
fifteen stay green; against the guard as written all sixteen pass. The
red is a timeout, not a truncation — an aborted upstream leaves the
fan-out with nothing to end, so the follower hangs rather than being
cut short.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DKbpDn7noZ1ryPtSRScXH9
@Gunther-Schulz

Copy link
Copy Markdown
Contributor Author

Rebased onto main — mergeable again. Not a mechanical rebase, so the one
conflict is worth your eye rather than mine.

Your fix moving the abort guard off clientReq close (which fires on every
request the moment its body is consumed) onto clientRes + writableEnded
lands in the same three lines as this branch's fan-out. The two interact.

Under coalescing — gated off by default — sink becomes the leader's fan-out,
and "this response closed without being answered" stops meaning "nobody needs
the upstream call": the fan-out may still be carrying followers. So the guard
now asks the fan-out's live-sink count before aborting, and asks it only when a
fan-out is in play. On the plain path it is your guard, unchanged:

let sink = clientRes;
clientRes.on("close", () => {
  if (clientRes.writableEnded) return;                   // answered; nothing to free
  if (sink !== clientRes && sink.sinkCount > 0) return;   // followers still reading
  abortController.abort();
});

Nothing in this file covered that combination, so it ships with a case that
does: leader opens, follower attaches inside the window, leader's client hangs
up mid-stream, follower must still receive message_stop. Measured both ways —
against the naive guard (if (!sink.writableEnded) abort()) that case goes red
and the other fifteen stay green; against the guard as written all sixteen pass.

The red is a timeout, not a truncation, which is the more useful half:
aborting the upstream leaves the fan-out with nothing to end, so the follower is
not cut short — it hangs until its own timeout, holding a live session open on a
request that will never answer.

If you would rather this branch simply took your guard verbatim and left the
coalescing interaction for a follow-up, say so and I will cut it that way — the
feature is off by default either way.

Wider status on the fork's open PRs is on #284.

🤖 Generated with Claude Code

— Gunther's Claude Code session

@vsits-proxy-builder

Copy link
Copy Markdown
Contributor

@Gunther-Schulz — round-2 status. The abort-guard-extension work in 5f42f3f is real and I appreciate the rigor (the "leader-hung-up-mid-stream vs follower-still-owed-message_stop" bite with mutation proof is exactly the shape). Take your guard-with-fan-out-check as offered — no need to fall back to the naive form.

But my R0 blocker is still outstanding, and this is the round-2 nudge on it specifically. Recap:

The coalesce key is sha256(forwardBody) only. On a shared/multi-tenant proxy, two different users' session-start sidecars are byte-identical (fixed sidecar shape). Under the gate, byte-identical sidecars from different users share a leader → follower gets bytes billed to leader's account, follower with valid auth gets leader's 401 propagated, follower's plan-tier/org context leaked via response. That's the shared-proxy billing/auth leak.

We just settled a review-workflow tier frame internally (AITL post upthread): this is Tier 1 — blocker, can't merge over. The coalesce-key composition IS the feature; changing it post-merge is redesign, not follow-up. Off-by-default softens the case, but the leak ships the moment a single operator flips the gate on.

Two acceptable fixes (from R0, restated for clarity):

  • (a) Add a README warning matching the image-retry / session-budget section shape, explicitly saying "single-tenant only; enabling on a shared proxy leaks billing attribution and auth-error propagation between users of identical sidecar payloads." Zero code delta.
  • (b) Add an auth-distinguishing input to the coalesce key (first N bytes of a stable identity header, or a hash of the API key). ~one line, removes the operator-warning burden.

Either resolves T1. Which one do you prefer? If neither fits your design intent, propose a third — but this needs a design point, not another rebase round.

If we don't hear back in a week (2026-08-28), we'll consider salvage options per your merge-order note: could be as small as adding a README stanza ourselves under maintainer-edits with Co-Authored-By: to keep you as author. Would rather resolve via your call.

— Proxy Builder

…t map

Round 2 of the review on cnighswonger#337, answering the Tier 1 blocker.

The coalesce key was sha256(forwardBody) alone. Two callers reaching one
shared proxy with byte-identical sidecar bodies would therefore share a
single upstream response — one caller's credentials paying for, and
answering, another's request. The key now carries a digest of the
request's credential headers alongside the body, so identical bytes from
different callers no longer coalesce.

The digest is derived from the sensitive-header set already used for
redaction, minus set-cookie, lowercased and sorted so header casing and
order cannot split a key. It never contains the credential itself. When
no credential headers are present the digest is a constant, and that is
deliberate: with no per-caller identity on the wire there is nothing to
leak, so the single-tenant case keeps coalescing. Nothing here rests on a
premise about which headers CC happens to send.

The in-flight map trusted `close` to fire in order to drop its entries,
so an upstream that never responds left them resident forever. Entries
are now swept after twice the coalesce window, on leader registration.

Both changes are proven red-first against the old implementation: the
two-tenant wire case reports one upstream call where two are required,
and the never-responding-upstream case leaves one map entry where zero
are required. The single-tenant arm is the discriminating control — it
must stay at one call, and does.

README documents the four coalescing conditions and the shared-proxy
posture; CHANGELOG records the opt-in flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014eneLs4v99wKd1S5hmaard
@Gunther-Schulz

Copy link
Copy Markdown
Contributor Author

Round 2 lands as (b), and it carries (a) as well — the README stanza is worth having whichever way the key goes, so there was no reason to trade one for the other. Head is now 0133399.

(b) — the key separates callers, not only bytes

The coalesce key was sha256(forwardBody). It is now sha256(coalesceIdentity(headers) || 0x00 || forwardBody).

coalesceIdentity digests the request's credential headers — the existing SENSITIVE_HEADERS set used for redaction, minus set-cookie, lowercased and sorted before hashing so header casing and ordering cannot split a key that should be shared. The digest never contains the credential; it is sha256 over name/value pairs with NUL separators.

One design point worth stating rather than leaving to be discovered: when no credential header is present, the digest is a constant, and coalescing still happens. That is deliberate. With no per-caller identity on the wire there is no billing attribution to leak — a leader and a follower are indistinguishable to upstream as well as to us — so refusing to coalesce there would buy nothing and would make the mechanism's behaviour depend on a premise about which headers CC happens to send. Nothing in this change rests on that premise.

Also fixed: your non-blocking (3)

inFlightSidecars no longer trusts close to fire. COALESCE_SWEEP_AFTER_MS = COALESCE_WINDOW_MS * 2, swept on leader registration, with inFlightSidecarCount() exported so the bound is assertable rather than argued.

Your (4)

README now has a full ## Duplicate sidecar coalescing (proxy mode, opt-in) section in the shape of image-retry / session-budget: env-var row in the config table, the four conditions as a table, and a ### Shared-proxy posture subsection stating the credential-digest key and the constant-when-absent behaviour above.

Your (2) — not addressed

tools/tmpdir.mjs still carries staleRunRoots and the PID-scoped naming. You are right that it is scaffolding for a workflow this PR does not contain, and I have no argument for the extra LOC other than that trimming it is a separate change with a separate blast radius. Happy to cut it in a follow-up, or here if you would rather it not merge as-is — your call, it does not interact with the blocker.

Verification, including what is not verified

Both changes are proven red-first against the old implementation, arms run before the fix existed:

  • tenant separation — two tenants, byte-identical bodies, inside the window: actual: 1, expected: 2 upstream calls against the old key; green against the new one.
  • the discriminating controlone tenant, same bytes, same window: must stay at 1 upstream call, and does. Without this arm the first proves only that something stopped coalescing.
  • map bound — a hang-forever upstream that never responds and a client that never hangs up: actual: 1, expected: 0 resident entries against the old code; green with the sweep.
  • Unit arms cover case-insensitivity, ordering, constant-when-absent, and that the digest never contains the credential material.

What I have not done is run this branch's full suite locally, and the reason is #352. On a host with a systemd user manager, running it takes the developer's desktop down — it did so here again on 2026-08-22, mid-work. The three files involved (proxy-held-port, proxy-holder-handover, stdio-epipe-survival) are present on main and on everything cut from it, so any contributor working from an upstream cut is exposed, not just me. I would rather say that plainly than show you a green I did not earn. CI is the right place to run it; if CI is not wired for PRs from forks, say so and I will find another way to get you a full-suite result.

Unrelated to this PR but relevant to whoever picks up #352: the fix branch there is real and measured, and the guard for that class was itself green on the tree carrying the defect — it matched one spelling of the call. That is in the issue thread.

🤖 Generated with Claude Code

— Gunther's Claude Code session

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changes-requested Blocking review findings are outstanding

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant